51 — 导入并优化场景

Three.js Journey · Bruno Simon · 难度:简单

51 — 导入并优化场景 课程海报

介绍

在之前的课程中,我们创建并导出了我们的场景。

现在我们可以把它导入到 Three.js 中,但我们还要尽可能地对模型进行优化。

设置

目前,我们的场景里只有一个白色立方体。

加载器已经添加到代码中了,而且已经支持 Draco:

/**
 * Loaders
 */
// Texture loader
const textureLoader = new THREE.TextureLoader()

// Draco loader
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('draco/')

// GLTF loader
const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

portal.glb 和 baked.jpg 文件位于 /static/ 文件夹中,不过你可以用你自己的文件替换它们。

同时还有一个 lil-gui 实例可用,我们会在下一节课中给它添加一些调节项。

抗锯齿(antialias)已经在 WebGLRenderer 上设置好了。

加载模型

我们可以先从加载模型开始。我们会把立方体保留在场景中,以确保一切正常工作。一旦我们能看到传送门场景,我们就会把立方体移除。

在加载器部分之后加载模型,并在控制台中测试结果:

/**
 * Model
 */
gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        console.log(gltf.scene)
    }
)

如果你没有在控制台日志中看到这个场景,请检查路径并查找错误。

现在我们来尝试把模型添加到场景中:

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        scene.add(gltf.scene)
    }
)

你应该能看到一些轮廓剪影。

问题在于,默认情况下,当 GLTF 导出 PBR 材质时,它们会被解释为 MeshStandardMaterial。而且,这种材质需要光照。

但我们不想要灯光,因为我们的场景是 baked(烘焙)过的,所有光照和阴影信息都已经包含在 texture 中了:

所以,我们要创建一个 MeshBasicMaterial 并将它应用到场景中的所有元素上。

由于我们要创建多个 material,我们需要在加载模型之前,在代码中为它们创建一个区域:

/**
 * Materials
 */
// Baked material
const bakedMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 })

场景中有很多对象。与其手动把 material 应用到每一个对象上,我们可以在加载回调中遍历(traverse)场景:

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        gltf.scene.traverse((child) =>
        {
            child.material = bakedMaterial
        })
        scene.add(gltf.scene)
    }
)

你应该能看到所有对象都显示出统一的红色。

现在,移除与白色立方体相关的代码。

加载 texture

要加载 texture,我们可以使用代码中已有的 textureLoader。

在 materials 之前加载它:

/**
 * Textures
 */
const bakedTexture = textureLoader.load('baked.jpg')

确保日志中没有出现任何错误。

要把这个 texture 应用到我们的场景上,我们可以通过 map 属性把它添加到 bakedMaterial 中:

const bakedMaterial = new THREE.MeshBasicMaterial({ map: bakedTexture })

虽然这看起来很有意思也很好笑,但这并不是我们想要的效果。

问题在于,我们 texture 的 Y 坐标被反转了。就好像我们的 texture 被垂直镜像了一样。

在软件 / 库之间,对于 texture 坐标中 Y 轴的方向存在分歧。在上面的图示中我们不太走运,但我们只需把 texture 翻转过来即可。

为此,把 bakedTexture 的 flipY 属性设置为 false:

bakedTexture.flipY = false

现在场景看起来好多了。

我们仍然需要处理灯柱灯和传送门。而且与 Blender 渲染相比,颜色看起来有些褪色。

修复颜色

我们的 baked texture 是用 sRGB 编码的,但 Three.js 并不知道这一点。

要设置 texture 的编码,把它的 colorSpace 属性改为 THREE.SRGBColorSpace:

bakedTexture.colorSpace = THREE.SRGBColorSpace

现在的颜色与 Blender 渲染中的完全一致。

修复发光对象

是时候处理灯柱灯和传送门了。

因为它们是自发光(emission)材质,应该看起来像是统一的亮色,这就是为什么我们可以使用 MeshBasicMaterial。

为灯柱灯创建一个 MeshBasicMaterial:

// Pole light material
const poleLightMaterial = new THREE.MeshBasicMaterial({ color: 0xffffe5 })

现在的问题是,我们要如何把这个 material 应用到场景中正确的对象上。

如果你在加载回调中打印出场景的 children,你会看到其中有 56 个 Meshes(这个数字可能会因你的模型而不同):

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        console.log(gltf.scene.children)
        
        // ...
    }
)

要解决如何把正确的 material 应用到正确的对象上,我们要编辑模型,为每个发光对象设置一个特定的名称。

在 Blender 中打开模型(使用你的 .blend 文件,或使用位于 /resources/ 文件夹中的文件):

启用 emissions 集合的选择(如果还没有启用的话):

相应地重命名每一个对象:

在 Blender 中,你不能给多个对象使用相同的名称,因为每个对象都有唯一的 ID。

选中所有内容(camera 和 light 除外)并将其导出为 glTF 2.0:

使用与上一节课相同的参数,并替换 /static/ 文件夹中的 portal.glb 文件:

剧透提醒:不要关闭 Blender!

在 Three.js 中,你可以通过 name 属性访问对象的名称:

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        console.log(gltf.scene.children[0].name)

        // ...
    }
)

现在我们能做的是,通过使用 name 属性在 gltf.scene.children 数组中进行查找。

在 JavaScript 中,你可以用 find(...) 方法在数组中“查找”:

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        gltf.scene.traverse((child) =>
        {
            child.material = bakedMaterial
        })
        scene.add(gltf.scene)

        // Get each object
        const portalLightMesh = gltf.scene.children.find((child) => child.name === 'portalLight')
        const poleLightAMesh = gltf.scene.children.find((child) => child.name === 'poleLightA')
        const poleLightBMesh = gltf.scene.children.find((child) => child.name === 'poleLightB')

        console.log(portalLightMesh)
        console.log(poleLightAMesh)
        console.log(poleLightBMesh)
    }
)

你应该能在控制台中看到这 3 个对象中的每一个。

如果你遇到错误或得到 undefined,请检查你的 JavaScript 以及 Blender 场景中的名称。此外,请确保你以正确的方式导出了模型。

现在我们可以用之前创建的 material 替换灯柱灯:

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        gltf.scene.traverse((child) =>
        {
            child.material = bakedMaterial
        })
        scene.add(gltf.scene)

        // Get each object
        const portalLightMesh = gltf.scene.children.find((child) => child.name === 'portalLight')
        const poleLightAMesh = gltf.scene.children.find((child) => child.name === 'poleLightA')
        const poleLightBMesh = gltf.scene.children.find((child) => child.name === 'poleLightB')

        // Apply materials
        poleLightAMesh.material = poleLightMaterial
        poleLightBMesh.material = poleLightMaterial
    }
)

你应该能看到你的 MeshBasicMaterial 应用在两个灯上。

为传送门创建一个 MeshBasicMaterial 并以同样的方式应用它:

// Portal light material
const portalLightMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })

// ...

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        gltf.scene.traverse((child) =>
        {
            child.material = bakedMaterial
        })
        scene.add(gltf.scene)

        // Get each object
        const portalLightMesh = gltf.scene.children.find((child) => child.name === 'portalLight')
        const poleLightAMesh = gltf.scene.children.find((child) => child.name === 'poleLightA')
        const poleLightBMesh = gltf.scene.children.find((child) => child.name === 'poleLightB')

        // Apply materials
        portalLightMesh.material = portalLightMaterial
        poleLightAMesh.material = poleLightMaterial
        poleLightBMesh.material = poleLightMaterial
    }
)

如果你的传送门颜色不是白色,不要花太多时间尝试找到正确的值,因为我们稍后会用一个 ShaderMaterial 来替换它。

提升性能

我们本可以就此停下,但我们犯了一些性能上的错误。

首先,我们可以用 Spector.js 测试 draw call(绘制调用)。

监控

如果你还记得性能那一课,Spector.js 是一个 Chrome 插件,它让我们能看到 GPU 为创建渲染所经历的不同步骤。

在撰写本课时,这个 Chrome 插件已经不再可用了。

你不需要安装这个插件也能跟着下面的步骤操作,但时刻关注你的场景是如何被渲染的,以便对它进行优化,这总是好的。

如果你在 Chrome 中安装了 Spector.js,点击扩展图标来激活它,再点击一次打开以下菜单:

点击红色圆圈开始处理渲染。这可能需要几秒钟。

处理完成后,应该会打开一个窗口,里面包含我们需要的所有细节。在左侧,你可以滚动查看渲染场景所采取的不同步骤。步骤越少越好:

性能可能已经足够好了,但如果你仔细想想,我们应该只有 4 个对象:

我们不需要分离 baked 对象,因为我们很确定不会移动它们。例如,如果我们移动了斧头,我们仍然会在树干上看到它的阴影。如果我们移动了栅栏,我们仍然会在地板上看到它的阴影。

为了提升性能,我们要合并这些 geometry。

合并 baked 对象

回到 Blender:

我们不想影响发光对象。确保把 emissions 集合设置为不可选中:

现在就把所有 geometry 合并会是个坏主意,因为之后我们可能就无法修改场景了。我们要先把它们复制一份,把所有内容放进另一个集合,然后再合并。

创建一个空的 merged 集合:

选中场景中的所有对象(不包括 camera、light 和发光对象),然后用 SHIFT + D 复制它们。在场景中点击一次(不要移动鼠标)来确认复制。

在这些复制出来的对象仍被选中时,按 M 把它们移动到 merged 集合中:

现在所有对象都已经复制到 merged 集合中了。你可以按 CTRL + J(Windows)或 CMD + J(MacOS)来合并它们。

停用所有其他集合,并检查你只有一个合并后的对象:

把这个单一对象的名字改为 baked:

重新启用 emissions 集合,以便能够选中其中的对象。

你应该保留你最初创建的集合,即使它们被停用了。这样万一我们需要做修改或重新 bake 场景时它们还在:

如果你切换到 Renderer 着色模式,你会看到这些 material 在合并后的对象上仍然有效。

这是因为 Blender 可以在一个 geometry 上处理多个 material。这不成问题,因为在导出时,我们让 Blender 不要导出 material。但如果你好奇想看没有 material 的场景是什么样子,在选中 baked 对象时进入 Materials Properties 标签页,然后点击 - 图标删除所有 material:

这应该会得到一个好看的白色场景。

现在我们可以用与之前相同的参数导出合并后的对象和发光对象:

你应该会在 Three.js 中得到完全相同的结果:

但让我们看看 Spector.js:

如你所见,我们的场景只用了 4 个步骤就被绘制出来。一个用于 baked 对象,另外每个发光灯各一个。

我们本可以合并两个灯柱灯,但就目前而言这已经足够了。

现在我们只有一个对象需要应用 baked texture,就不再需要遍历已加载场景的所有子对象了。

我们可以用与发光对象相同的方法来查找它:

gltfLoader.load(
    'portal.glb',
    (gltf) =>
    {
        scene.add(gltf.scene)

        // Get each object
        const bakedMesh = gltf.scene.children.find((child) => child.name === 'baked')
        const portalLightMesh = gltf.scene.children.find((child) => child.name === 'portalLight')
        const poleLightAMesh = gltf.scene.children.find((child) => child.name === 'poleLightA')
        const poleLightBMesh = gltf.scene.children.find((child) => child.name === 'poleLightB')

        // Apply materials
        bakedMesh.material = bakedMaterial
        portalLightMesh.material = portalLightMaterial
        poleLightAMesh.material = poleLightMaterial
        poleLightBMesh.material = poleLightMaterial
    }
)

你应该会得到相同的结果。如果没有,请确保你把合并后的对象名字改成了 baked,并且导出了它以及那 3 个发光对象。

在下一节课中,我们会给场景添加一些细节。